Fix client reconnection after page refresh#3117
Conversation
WalkthroughServer identity now derives from persistent tokens: clientIDs are assigned and mapped from persistentIDs. Join/rejoin, kick, and intent flows use persistentID mappings and stamped intents. Server messages include myClientID and lobbyCreatorClientID; clients stop sending clientID and server stamps intents with the resolved clientID. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor BrowserClient as "Browser Client"
participant Worker as "Worker (WS)"
participant GM as "GameManager"
participant GS as "GameServer"
participant PIDMap as "persistentIdToClientId"
BrowserClient->>Worker: WS connect + Authorization: Bearer <token>
Worker->>GS: validate token -> persistentID
Worker->>GM: join/rejoin request (gameID, persistentID)
GM->>GS: resolve clientID for persistentID
GS->>PIDMap: lookup or create clientID for persistentID
PIDMap-->>GS: clientID
GS-->>GM: resolved clientID
GM-->>Worker: join status + assigned clientID
Worker->>BrowserClient: send `lobby_info` / `start` (includes myClientID, lobbyCreatorClientID)
BrowserClient->>Worker: send intents (no clientID)
Worker->>GS: forward intents
GS->>GS: stamp intents with resolved clientID, enqueue/process turn
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. No actionable comments were generated in the recent review. 🎉 🧹 Recent nitpick comments
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/server/GameServer.ts`:
- Around line 162-186: The reconnect branch in joinClient skips the persistentID
check that rejoinClient enforces, allowing session hijack; update the reconnect
path in joinClient to validate that incoming client.persistentID matches
existingClient.persistentID, and if it does not, log a warning and close/reject
the new socket (do not replace existingClient.ws or add to activeClients); if it
matches, continue with updating existingClient.ws, lastPing,
markClientDisconnected(..., false), addListeners(existingClient) and use
existingClient.ws when calling sendStartGameMsg.
- Around line 169-171: When replacing an existing client's WebSocket (the code
that sets existingClient.ws = client.ws and updates existingClient.lastPing),
first grab the previous WebSocket (e.g., const previousWs = existingClient.ws),
and if it exists and is not already CLOSED/closing, call previousWs.close()
(optionally with a normal close code) to avoid leaking the old connection; do
NOT call this.websockets.delete(previousWs) — leave Set cleanup to the class
end() method. Ensure you then assign existingClient.ws = client.ws and update
lastPing as before.
🧹 Nitpick comments (1)
src/server/GameServer.ts (1)
182-184: UseexistingClient.wsfor clarity.After line 170, both
client.wsandexistingClient.wspoint to the same WebSocket. UsingexistingClient.wshere would be more consistent and easier to follow sinceexistingClientis what's added toactiveClients.✏️ Suggested change
// Send start message if game has already started if (this._hasStarted) { - this.sendStartGameMsg(client.ws, 0); + this.sendStartGameMsg(existingClient.ws, 0); }
There was a problem hiding this comment.
Pull request overview
This PR implements client reconnection functionality after page refresh by modifying the joinClient method in GameServer.ts. Previously, when a client attempted to rejoin with the same clientID, a warning was logged and the client was rejected. The new code now handles reconnection by updating the WebSocket reference, restoring the client's connected status, and re-sending game state if needed.
Changes:
- Modified
joinClientto detect and handle reconnection scenarios when a client with the same clientID already exists - Added logic to update WebSocket references, mark client as connected, restore to activeClients, and resend game start information
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
f5aea08 to
5479459
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/client/HostLobbyModal.ts (1)
638-666:⚠️ Potential issue | 🟡 MinorSet lobbyCreatorClientID after joining.
It now stays empty, so lobby-player-view cannot show the host or “you”. Consider setting it from the first client returned in pollPlayers (same as JoinLobbyModal) or from lobby_info.
✅ Suggested fix (pollPlayers)
- .then((data: GameInfo) => { - this.clients = data.clients ?? []; - }); + .then((data: GameInfo) => { + this.clients = data.clients ?? []; + if (!this.lobbyCreatorClientID && this.clients.length > 0) { + this.lobbyCreatorClientID = this.clients[0].clientID; + } + });src/server/Worker.ts (1)
1-1:⚠️ Potential issue | 🟡 MinorFix Prettier formatting before merge.
The CI pipeline reports formatting issues in this file. Run
npx prettier --write src/server/Worker.tsto fix.
🤖 Fix all issues with AI agents
In `@src/client/ClientGameRunner.ts`:
- Around line 85-98: The file fails Prettier formatting; run the project's
formatter and commit the formatted changes to satisfy CI. Open
src/client/ClientGameRunner.ts (look for symbols like onconnect, onmessage,
terrainLoad, lobbyConfig) and apply the project's Prettier configuration (or run
npm/yarn script such as "npm run format" or "yarn format") to reformat the file,
then stage and push the updated file so the prettier check passes.
In `@src/client/JoinLobbyModal.ts`:
- Around line 346-350: startTrackingLobby currently clears this.currentClientID
and nothing sets it afterward; locate the handler that processes the server's
lobby_info message (or wherever yourClientID is received) and assign that value
into this.currentClientID (e.g., set this.currentClientID = message.yourClientID
or the equivalent field) so the lobby-player-view can identify the local player;
ensure the assignment happens after startTrackingLobby is called and consider
emitting/updating any dependent state or view update methods so the UI
re-renders with the new client id.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/server/GameServer.ts (1)
193-211:⚠️ Potential issue | 🟡 MinorRejected clients in
joinClientleave unclosed sockets in thewebsocketsSet.At line 194,
client.wsis added tothis.websocketsbefore any validation. When the client is rejected (full lobby at line 200, IP limit at line 225), the socket is never closed. While thewebsocketsSet cleanup-in-end()pattern is intentional, rejected clients should still have their socket closed so they know to stop waiting.The full-lobby path (line 204) sends an error message but doesn't close the socket. The IP-limit path (line 225) doesn't even send a message.
Close sockets for rejected clients
client.ws.send( JSON.stringify({ type: "error", error: "full-lobby", } satisfies ServerErrorMessage), ); + client.ws.close(1000, "full-lobby"); return; }this.log.warn("cannot add client, already have 3 ips", { clientID: client.clientID, clientIP: ipAnonymize(client.ip), }); + client.ws.close(1008, "too many connections from same IP"); return;
🧹 Nitpick comments (2)
src/client/HostLobbyModal.ts (1)
1072-1084:pollPlayersnow overlaps withLobbyInfoEvent— consider removing it.Both
pollPlayers(HTTP polling every 1s at line 699) andhandleLobbyInfo(via WebSocketLobbyInfoEvent) write tothis.clients. The server already broadcasts lobby info every 1s via WebSocket. Having both can cause flickering if the HTTP response arrives slightly stale compared to the WebSocket push, or vice versa.Since the WebSocket-based
LobbyInfoEventis the newer, more reliable source (and also provideslobbyCreatorClientID), the HTTP polling inpollPlayerslooks redundant for the lobby phase.src/server/GameServer.ts (1)
160-191:isKicked,getClientIdForPersistentId,getOrCreateClientId— well-structured identity resolution.The layered approach is easy to follow:
isKickedresolvesclientID → persistentID → kickedPersistentIdsgetClientIdForPersistentIddoes map lookup + kick checkgetOrCreateClientIdwraps the above and generates a new ID when neededOne small note:
getOrCreateClientIdcheckskickedPersistentIdsdirectly at line 179, then callsgetClientIdForPersistentIdat line 183 which checks it again. The double check is harmless but redundant —getClientIdForPersistentIdalready returnsnullfor kicked IDs.Remove redundant kick check
public getOrCreateClientId(persistentID: string): ClientID | null { - // Check if this persistentID has been kicked - if (this.kickedPersistentIds.has(persistentID)) { - return null; - } - const existingClientID = this.getClientIdForPersistentId(persistentID); if (existingClientID) { return existingClientID; } + + // Don't create new IDs for kicked players + if (this.kickedPersistentIds.has(persistentID)) { + return null; + } + // Generate new clientID for new player const newClientID = generateID(); this.persistentIdToClientId.set(persistentID, newClientID); return newClientID; }
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/server/GameServer.ts (2)
217-228:⚠️ Potential issue | 🟡 MinorIP-limit rejection silently drops the connection with no error and no socket close.
When the 3-IP limit is hit, the method returns without sending an error message or closing the WebSocket. The socket was already added to
this.websocketsat line 192, so it will linger untilend(). The client has no idea what happened.Consider sending an error message and closing the socket, similar to the full-lobby path.
Proposed fix
this.log.warn("cannot add client, already have 3 ips", { clientID: client.clientID, clientIP: ipAnonymize(client.ip), }); + client.ws.send( + JSON.stringify({ + type: "error", + error: "too-many-connections", + } satisfies ServerErrorMessage), + ); + client.ws.close(1008, "too many connections from same IP"); return;
194-209:⚠️ Potential issue | 🟡 MinorFull-lobby rejection sends error but does not close the socket.
Same pattern as the IP-limit path: after sending the
"full-lobby"error on line 202,client.wsstays open and lingers inthis.websocketsuntilend(). The client must handle the error and close on its own. Consider closing the socket server-side for a clean lifecycle.Proposed fix
client.ws.send( JSON.stringify({ type: "error", error: "full-lobby", } satisfies ServerErrorMessage), ); + client.ws.close(1000, "full-lobby"); return;
🧹 Nitpick comments (2)
src/server/GameServer.ts (2)
167-189:getOrCreateClientIdleaves an orphan mapping whenjoinClientlater rejects the client.If
getOrCreateClientIdcreates a newpersistentIdToClientIdentry and thenjoinClientrejects the client (full lobby, IP limit), the mapping persists butallClientsnever gets populated for thatclientID. This is not a bug today becauserejoinClientchecksallClientsafterward (line 275–276), but it does meanpersistentIdToClientIdgrows monotonically with every uniquepersistentIDthat ever attempted to join — even if they were rejected.For a short-lived game server this is fine, but worth a note if game lifetimes ever grow long.
649-681:sendStartGameMsgfinds the client by WebSocket reference — fragile ifwsis shared or reused.The lookup
this.activeClients.find((c) => c.ws === ws)works today because each client has a uniquews. But if this method were ever called with a WebSocket that is not yet assigned (or after a reconnect race), the lookup silently fails and logs a warning (line 653).A safer signature would accept
client: Clientdirectly (the caller always has it). This avoids the indirect lookup entirely.Proposed change
- private sendStartGameMsg(ws: WebSocket, lastTurn: number) { - // Find which client this websocket belongs to - const client = this.activeClients.find((c) => c.ws === ws); - if (!client) { - this.log.warn("Could not find client for websocket in sendStartGameMsg"); - return; - } + private sendStartGameMsg(client: Client, lastTurn: number) {Then update all call sites to pass
clientinstead ofclient.ws/c.ws.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/server/GameServer.ts (1)
175-217:⚠️ Potential issue | 🟠 MajorState is mutated before rejection checks — stale mapping on reject.
Lines 180-181 add the WebSocket to
this.websocketsand set thepersistentIdToClientIdmapping before the full-lobby check (line 183) and the IP-limit check (line 206). When either check returns"rejected":
persistentIdToClientIdmaps to aclientIDthat never entersallClients. A subsequentrejoinClientcall will find the staleclientID, look it up inallClients, getundefined, and fail — so the player can't silently reconnect until they do a fresh join (which overwrites the mapping). Not catastrophic, but confusing.- The WebSocket sits in
this.websocketsuntilend(), taking up memory for the entire game lifetime.Also, the IP-limit rejection (line 216) sends no error message to the client, unlike the full-lobby path. The client has no idea why the connection went silent.
Proposed fix — move state mutation after all rejection checks
public joinClient(client: Client): "joined" | "kicked" | "rejected" { if (this.kickedPersistentIds.has(client.persistentID)) { return "kicked"; } - this.websockets.add(client.ws); - this.persistentIdToClientId.set(client.persistentID, client.clientID); - if ( this.gameConfig.maxPlayers && this.activeClients.length >= this.gameConfig.maxPlayers ) { this.log.warn(`cannot add client, game full`, { clientID: client.clientID, }); client.ws.send( JSON.stringify({ type: "error", error: "full-lobby", } satisfies ServerErrorMessage), ); return "rejected"; } this.log.info("client joining game", { clientID: client.clientID, persistentID: client.persistentID, clientIP: ipAnonymize(client.ip), }); if ( this.gameConfig.gameType === GameType.Public && this.activeClients.filter( (c) => c.ip === client.ip && c.clientID !== client.clientID, ).length >= 3 ) { this.log.warn("cannot add client, already have 3 ips", { clientID: client.clientID, clientIP: ipAnonymize(client.ip), }); + client.ws.send( + JSON.stringify({ + type: "error", + error: "ip-limit", + } satisfies ServerErrorMessage), + ); return "rejected"; } + this.websockets.add(client.ws); + this.persistentIdToClientId.set(client.persistentID, client.clientID); + if (this.config.env() === GameEnv.Prod) {
🤖 Fix all issues with AI agents
In `@src/server/Worker.ts`:
- Around line 452-463: joinClient can return "rejected" but Worker.ts doesn't
handle it, leaving the WebSocket open and stale entries created by
GameServer.joinClient (this.websockets and persistentIdToClientId). Update the
joinResult handling in Worker.ts to treat "rejected" like the other failure
cases: call ws.close(1002, "Cannot join game" or "Lobby full") and then remove
the socket and mappings that GameServer.joinClient already added (clear the
entry in GameServer.websockets for the new client id and delete the
persistentIdToClientId mapping for the persistent id) so there are no dangling
sockets or stale mappings after a rejection.
|
(merged cuz evan probably forgot to click) |
Description:
Please complete the following:
Please put your Discord username so you can be contacted if a bug or regression is found:
w.o.n